Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 37b3f266697295822035dc1a5a2c857725ce3865


Parents : dd2b273
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T14:29:16-05:00

feat: improve network visualiser with silent update handling and improved icon rendering logic

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index ad176a72..4ffe5c6f 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 01486cc1..cbd4576d 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -269,7 +269,13 @@ export default {
persistVisualiserAutoReload(val === true, { emit: false });
}
if (val) {
- this.manualUpdate();
+ // Already painted: quiet refresh so enabling auto-update does not flash
+ // the loading overlay or re-settle the whole graph.
+ if (this.displayNodeCount > 0) {
+ this.onAutoReload();
+ } else {
+ this.manualUpdate();
+ }
}
this.restartAutoReloadInterval();
},
@@ -1125,11 +1131,11 @@ export default {
this.restartAutoReloadInterval();
},
async manualUpdate() {
- if (this.isLoading) return;
+ if (this.isLoading || this.isUpdating) return;
this.isLoading = true;
this.isUpdating = true;
try {
- await this.update();
+ await this.update({ silent: false });
} finally {
this.isLoading = false;
this.isUpdating = false;
@@ -1139,7 +1145,7 @@ export default {
if (!this.autoReload || this.isUpdating || this.isLoading) return;
this.isUpdating = true;
try {
- await this.update();
+ await this.update({ silent: true });
} finally {
this.isUpdating = false;
}
@@ -1249,17 +1255,27 @@ export default {
};
}
},
- async update() {
- this.loadingStatus = "Fetching basic info...";
- this.currentBatch = 0;
- this.totalBatches = 0;
+ async update(options = {}) {
+ const silent = options.silent === true;
+ const alreadyPainted = this.displayNodeCount > 0;
+
+ if (!silent) {
+ this.loadingStatus = "Fetching basic info...";
+ this.currentBatch = 0;
+ this.totalBatches = 0;
+ }
await this.getConfig();
if (this.abortController.signal.aborted) return;
const identityHash = this.config?.identity_hash;
+ /*
+ * Cold open only: restore IndexedDB cache for a fast first paint.
+ * Auto-refresh must not reload cache (would overwrite live path data
+ * and rebuild the graph twice, which looks like a UI reset).
+ */
let paintedFromCache = false;
- if (identityHash) {
+ if (identityHash && !alreadyPainted) {
const cached = await loadVisualiserCache(identityHash);
if (cached?.pathTable?.length) {
this.pathTable = cached.pathTable;
@@ -1272,13 +1288,15 @@ export default {
await Promise.all([this.getInterfaceStats(), this.getConversations(), this.getDiscoveredInterfaces()]);
if (this.abortController.signal.aborted) return;
- if (paintedFromCache && this.pathTable.length > 0) {
+ if (!silent && paintedFromCache && this.pathTable.length > 0) {
this.loadingStatus = "Restoring cached graph...";
- await this.processVisualization();
+ await this.processVisualization({ silent: false });
if (this.abortController.signal.aborted) return;
}
- this.loadingStatus = "Fetching network data...";
+ if (!silent) {
+ this.loadingStatus = "Fetching network data...";
+ }
await this.getPathTableBatch();
if (this.abortController.signal.aborted) return;
/*
@@ -1288,11 +1306,12 @@ export default {
await this.ensureAnnouncesForPathHashes({ reset: false });
if (this.abortController.signal.aborted) return;
- await this.processVisualization();
+ await this.processVisualization({ silent });
if (this.abortController.signal.aborted) return;
await this.persistVisualiserCache();
},
- async processVisualization() {
+ async processVisualization(options = {}) {
+ const silent = options.silent === true;
await new Promise((r) => {
requestAnimationFrame(r);
});
@@ -1300,7 +1319,9 @@ export default {
const runId = ++this.vizRunGeneration;
- this.loadingStatus = "Processing visualization...";
+ if (!silent) {
+ this.loadingStatus = "Processing visualization...";
+ }
/*
* Invalidate any in-flight icon-generation work. Each call to
@@ -1315,9 +1336,11 @@ export default {
* Pause vis-network physics for the bulk update. WASM may settle
* starting positions, then Live Layout re-enables JS physics so the
* graph keeps moving without requiring a drag.
+ * Silent auto-refresh keeps physics running so existing nodes do not jump.
*/
const physicsWasOn = this.network && this.enablePhysics;
- if (this.network) {
+ const pausePhysics = Boolean(this.network && !silent);
+ if (pausePhysics) {
this.network.setOptions({
physics: { enabled: false },
edges: { smooth: VIZ_EDGE_SMOOTH },
@@ -1325,7 +1348,7 @@ export default {
}
try {
- await this._processVisualizationGraph(runId);
+ await this._processVisualizationGraph(runId, { silent });
} finally {
if (runId === this.vizRunGeneration) {
if (this.webglEngine) {
@@ -1339,7 +1362,7 @@ export default {
edges: { smooth: VIZ_EDGE_SMOOTH },
});
}
- if (this.network && !this.physicsPausedForDrag) {
+ if (pausePhysics && this.network && !this.physicsPausedForDrag) {
this.network.setOptions({
physics: { enabled: Boolean(physicsWasOn || this.enablePhysics) },
edges: { smooth: VIZ_EDGE_SMOOTH },
@@ -1351,7 +1374,8 @@ export default {
}
}
},
- async _processVisualizationGraph(runId) {
+ async _processVisualizationGraph(runId, options = {}) {
+ const silent = options.silent === true;
const isCurrentRun = () => runId === this.vizRunGeneration && !this.abortController.signal.aborted;
const processedNodeIds = new Set();
const processedEdgeIds = new Set();
@@ -1385,7 +1409,9 @@ export default {
const isDarkMode = document.documentElement.classList.contains("dark");
this.totalNodesToLoad = this.pathTable.length;
this.loadedNodesCount = 0;
- this.loadingStatus = "Building graph...";
+ if (!silent) {
+ this.loadingStatus = "Building graph...";
+ }
const announcePayload = {};
for (const [hash, announce] of Object.entries(this.announces || {})) {
@@ -1620,25 +1646,36 @@ export default {
}));
}
- this.loadingStatus = "Settling layout...";
+ if (!silent) {
+ this.loadingStatus = "Settling layout...";
+ }
const layoutNodes = Array.isArray(graph.layout_nodes) ? graph.layout_nodes : [];
const layoutEdges = Array.isArray(graph.layout_edges) ? graph.layout_edges : [];
- // With Live Layout off, keep existing coordinates. Only settle when
- // physics is on, or when nodes still lack cached positions.
- const missingPositions = layoutNodes.some((n) => {
+ /*
+ * Place only nodes that still lack coordinates. Existing nodes stay
+ * fixed so auto-refresh spawns newcomers without resetting the map.
+ */
+ const missingIds = new Set();
+ for (const n of layoutNodes) {
const p = posById[n.id];
- return !(p && Number.isFinite(p.x) && Number.isFinite(p.y));
- });
- const shouldSettle =
- layoutNodes.length > 0 && (this.enablePhysics || (isVisualiserWasmReady() && missingPositions));
+ if (!(p && Number.isFinite(p.x) && Number.isFinite(p.y))) {
+ missingIds.add(n.id);
+ }
+ }
+ const shouldSettle = missingIds.size > 0 && isVisualiserWasmReady();
if (shouldSettle) {
+ const settleNodes = layoutNodes.map((n) => ({
+ ...n,
+ fixed: Boolean(n.fixed) || !missingIds.has(n.id),
+ }));
const settled = settleLayout({
- nodes: layoutNodes,
+ nodes: settleNodes,
edges: layoutEdges,
iterations: 0,
});
const positions = settled?.positions || {};
for (const node of graphNodes) {
+ if (!missingIds.has(node.id)) continue;
const p = positions[node.id];
if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
node.x = p.x;
@@ -1669,7 +1706,9 @@ export default {
this.currentBatch = 0;
if (this.webglEngine && this.rendererMode === "webgl") {
- this.loadingStatus = "Uploading scene...";
+ if (!silent) {
+ this.loadingStatus = "Uploading scene...";
+ }
this.webglEngine.setGraph(graphNodes, graphEdges);
const counts = this.webglEngine.getCounts();
this.graphNodeCount = counts.nodes;
@@ -1694,7 +1733,9 @@ export default {
if (batchNodes.length > 0) this.nodes.update(batchNodes);
if (batchEdges.length > 0) this.edges.update(batchEdges);
this.loadedNodesCount = Math.min(this.pathTable.length, i + chunkSize);
- this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`;
+ if (!silent) {
+ this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`;
+ }
if (this.pathTable.length > VIZ_SYNC_PATH_THRESHOLD) {
await yieldToMain();
}

diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
index c6ea9ea0..f6573bd1 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -29,9 +29,9 @@
@click.stop="$emit('manual-update')"
>
<MaterialDesignIcon
- :icon-name="isUpdating || isLoading ? 'loading' : 'refresh'"
+ :icon-name="isLoading ? 'loading' : 'refresh'"
class="w-4 h-4 sm:w-5 sm:h-5"
- :class="{ 'animate-spin': isUpdating || isLoading }"
+ :class="{ 'animate-spin': isLoading }"
/>
</button>
<div class="w-5 sm:w-6 flex justify-center">

diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index f81ab6a6..da2ee5d8 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -237,7 +237,7 @@ export function isGlyphStyleVisualiserIcon(url) {
/**
* Prepare atlas RGBA pixels for upload.
*
- * - opaque: force non-empty pixels to a=255 (RGB PNG uploads)
+ * - opaque: keep soft alpha (logo AA). Only promote RGB-with-a=0 PNG quirks.
* - glyph: keep bright glyph coverage as soft alpha (preserves AA), clear fill
*
* @param {Uint8ClampedArray|Uint8Array} data RGBA buffer (mutated)
@@ -286,13 +286,67 @@ export function prepareVisualiserIconPixels(data, mode = "opaque") {
data[i + 3] = alpha;
if (alpha >= 24) glyphPixels += 1;
}
- } else {
+ } else if (a === 0 && (r | g | b)) {
+ // Some RGB PNGs store color with a=0. Promote those only.
+ // Never crush existing soft alpha (RNS logo fringe looked jagged).
data[i + 3] = 255;
}
}
return { painted, glyphPixels };
}
+/**
+ * Downscale large bitmaps in steps before the final atlas blit.
+ * A single 512→116 drawImage looks soft/jagged on HiDPI discs.
+ * @param {CanvasRenderingContext2D} ctx
+ * @param {CanvasImageSource} source
+ * @param {number} sw
+ * @param {number} sh
+ * @param {number} dx
+ * @param {number} dy
+ * @param {number} dw
+ * @param {number} dh
+ */
+export function drawImageToAtlasCell(ctx, source, sw, sh, dx, dy, dw, dh) {
+ if (!ctx || !source || !(dw > 0 && dh > 0 && sw > 0 && sh > 0)) return;
+ ctx.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in ctx) {
+ ctx.imageSmoothingQuality = "high";
+ }
+ if (typeof document === "undefined" || (sw <= dw * 2 && sh <= dh * 2)) {
+ ctx.drawImage(source, dx, dy, dw, dh);
+ return;
+ }
+ let curW = sw;
+ let curH = sh;
+ let cur = source;
+ const temps = [];
+ try {
+ while (curW > dw * 2 || curH > dh * 2) {
+ const nextW = Math.max(dw, Math.ceil(curW / 2));
+ const nextH = Math.max(dh, Math.ceil(curH / 2));
+ const tmp = document.createElement("canvas");
+ tmp.width = nextW;
+ tmp.height = nextH;
+ const tctx = tmp.getContext("2d", { alpha: true });
+ if (!tctx) break;
+ tctx.imageSmoothingEnabled = true;
+ if ("imageSmoothingQuality" in tctx) {
+ tctx.imageSmoothingQuality = "high";
+ }
+ tctx.drawImage(cur, 0, 0, nextW, nextH);
+ temps.push(tmp);
+ cur = tmp;
+ curW = nextW;
+ curH = nextH;
+ }
+ ctx.drawImage(cur, dx, dy, dw, dh);
+ } finally {
+ // Drop temp canvases promptly (no explicit dispose API).
+ temps.length = 0;
+ }
+}
+
function createIconAtlas(gl) {
const width = ATLAS_COLS * ATLAS_CELL;
const height = ATLAS_ROWS * ATLAS_CELL;
@@ -302,6 +356,9 @@ function createIconAtlas(gl) {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ // Cap mip depth so neighbouring atlas cells do not bleed into logos.
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_BASE_LEVEL, 0);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAX_LEVEL, 2);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.generateMipmap(gl.TEXTURE_2D);
@@ -344,15 +401,15 @@ function createIconAtlas(gl) {
scratchCtx.restore();
return false;
}
- // Pad slightly so circular badges do not clip AA fringes at the cell edge.
- const pad = 2;
+ // Pad so circular clip and mip filtering do not chew logo AA.
+ const pad = isGlyphStyleVisualiserIcon(url) ? 2 : 6;
const fit = ATLAS_CELL - pad * 2;
const scale = Math.min(fit / sw, fit / sh);
const dw = Math.max(1, Math.round(sw * scale));
const dh = Math.max(1, Math.round(sh * scale));
const dx = Math.floor((ATLAS_CELL - dw) / 2);
const dy = Math.floor((ATLAS_CELL - dh) / 2);
- scratchCtx.drawImage(source, dx, dy, dw, dh);
+ drawImageToAtlasCell(scratchCtx, source, sw, sh, dx, dy, dw, dh);
const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL);
const data = pixels.data;
const mode = isGlyphStyleVisualiserIcon(url) ? "glyph" : "opaque";

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index 7d967cf5..c72e1c59 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -608,4 +608,41 @@ describe("NetworkVisualiser.vue", () => {
expect(wrapper.vm.graphEdgeCount).toBe(2);
expect(scheduleSpy).toHaveBeenCalled();
});
+
+ it("onAutoReload uses silent update without loading overlay", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.autoReload = true;
+ wrapper.vm.graphNodeCount = 5;
+ wrapper.vm.rendererMode = "webgl";
+ const updateSpy = vi.spyOn(wrapper.vm, "update").mockResolvedValue();
+
+ await wrapper.vm.onAutoReload();
+
+ expect(updateSpy).toHaveBeenCalledWith({ silent: true });
+ expect(wrapper.vm.isLoading).toBe(false);
+ expect(wrapper.vm.isUpdating).toBe(false);
+ });
+
+ it("silent update paints once and never sets isLoading", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.graphNodeCount = 4;
+ wrapper.vm.rendererMode = "webgl";
+ wrapper.vm.config = { display_name: "Me", identity_hash: "deadbeef" };
+ const processSpy = vi.spyOn(wrapper.vm, "processVisualization").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "getConfig").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "getInterfaceStats").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "getConversations").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "getDiscoveredInterfaces").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "getPathTableBatch").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "ensureAnnouncesForPathHashes").mockResolvedValue();
+ vi.spyOn(wrapper.vm, "persistVisualiserCache").mockResolvedValue();
+
+ await wrapper.vm.update({ silent: true });
+
+ expect(wrapper.vm.isLoading).toBe(false);
+ expect(processSpy).toHaveBeenCalledTimes(1);
+ expect(processSpy).toHaveBeenCalledWith({ silent: true });
+ });
});

diff --git a/tests/frontend/NetworkVisualiserToolbar.test.js b/tests/frontend/NetworkVisualiserToolbar.test.js
index bcb64121..797f2e46 100644
--- a/tests/frontend/NetworkVisualiserToolbar.test.js
+++ b/tests/frontend/NetworkVisualiserToolbar.test.js
@@ -54,9 +54,14 @@ describe("NetworkVisualiserToolbar", () => {
expect(icons).not.toContain(undefined);
});
- it("uses loading icon while updating", () => {
- const wrapper = mountToolbar({ isUpdating: true });
- const icons = wrapper.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
- expect(icons).toContain("loading");
+ it("uses loading icon only for manual loading, not auto-update busy", () => {
+ const autoBusy = mountToolbar({ isUpdating: true, isLoading: false });
+ const autoIcons = autoBusy.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
+ expect(autoIcons).toContain("refresh");
+ expect(autoIcons).not.toContain("loading");
+
+ const manualBusy = mountToolbar({ isUpdating: true, isLoading: true });
+ const manualIcons = manualBusy.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
+ expect(manualIcons).toContain("loading");
});
});

diff --git a/tests/frontend/networkVisualiserNodeLook.test.js b/tests/frontend/networkVisualiserNodeLook.test.js
index 54dfd562..385f3387 100644
--- a/tests/frontend/networkVisualiserNodeLook.test.js
+++ b/tests/frontend/networkVisualiserNodeLook.test.js
@@ -67,11 +67,30 @@ describe("visualiser WebGL node look regressions", () => {
expect(data[3]).toBeLessThan(220);
});
- it("opaque mode forces alpha on RGB pixels for logo uploads", () => {
- const data = new Uint8ClampedArray([10, 20, 30, 0, 0, 0, 0, 0]);
+ it("opaque mode preserves soft alpha and only promotes a=0 RGB pixels", () => {
+ const data = new Uint8ClampedArray([
+ 10,
+ 20,
+ 30,
+ 0, // RGB quirk: promote alpha
+ 40,
+ 50,
+ 60,
+ 90, // soft fringe: keep
+ 0,
+ 0,
+ 0,
+ 0, // empty
+ ]);
const { painted } = prepareVisualiserIconPixels(data, "opaque");
- expect(painted).toBe(1);
+ expect(painted).toBe(2);
expect(data[3]).toBe(255);
+ expect(data[7]).toBe(90);
+ });
+
+ it("stepwise atlas blit is exported for large logo downscales", async () => {
+ const { drawImageToAtlasCell } = await import("@/js/networkVisualiserWebGL.js");
+ expect(typeof drawImageToAtlasCell).toBe("function");
});
it("falls back to opaque when glyph extraction would wipe the icon", () => {


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────